Cause: No set_visible on Wayland#
While developing Rustle (my own music software), I needed to implement the "minimize to system tray" feature:
- When clicking the close button, the window hides instead of exiting the program.
- When clicking the tray icon, the window reappears.
- The program continues to run in the background (daemon mode).
Although using daemon mode allows for background operation, the default strategy of Iced and winit is to close the entire window and bring it back when needed. However, since I plan to use GPU rendering instead of software rendering, creating a new GPU context, reinitializing the Vulkan lifecycle, and then calling wgpu to draw the software interface takes a cold start process of about 500ms! Therefore, it is necessary to preserve the Vulkan/OpenGL lifecycle, which means the window cannot be destroyed but should be made invisible.
On X11, this is straightforward—just call window.set_visible(false/true). But on Wayland:
// winit's Wayland implementation
pub fn set_visible(&self, _visible: bool) {
// Not possible on Wayland.
}
winit has directly abandoned this feature, with a comment stating "Not possible on Wayland." (How is that possible?)
After consulting the Wayland protocol documentation, I found that the design philosophy of Wayland is completely different from X11:
- No global window manager API: Clients cannot directly manipulate the display state of windows.
- Compositor controls everything: The display, hiding, and position of windows are determined by the compositor.
- Only
set_minimized: But this operation is one-way—the program cannot restore a minimized window through code.
I couldn't find a similar issue anywhere online. But is there really no way?
Exploration: How do GTK and Chromium do it?#
GTK Implementation#
Looking at the GTK source code revealed a key clue:
// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);
wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
wl_surface_commit(impl->display_server.wl_surface);
_gdk_window_clear_update_area(window);
}
Key Discovery: GTK hides the window by using wl_surface_attach(NULL)!
XDG Shell Protocol Specification#
Consulting the XDG Shell protocol documentation, I found the official explanation:
Attaching a null buffer to a toplevel unmaps the surface.
The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.
This means:
- Hide:
attach(NULL)+commit()→ surface is unmaped. - Show:
commit()→ triggers configure event → re-renders.
Chromium Implementation#
Further research into Chromium's Wayland implementation:
// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
shell_toplevel_.reset(); // Destroy xdg_toplevel
connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}
// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
if (!CreateShellToplevel()) { ... } // Recreate xdg_toplevel
}
Chromium adopts a more aggressive approach—destroying and rebuilding the xdg_toplevel. However, I later found that this method causes the compositor to crash on Hyprland (is that right?).
Implementation: Modifying winit#
Architecture Overview#
┌────────────────────────────┐
│ Rustle (Application Layer) │
├────────────────────────────┤
│ iced (GUI Framework) │
├────────────────────────────┤
│ iced_winit (Window Management) │
├────────────────────────────┤
│ winit (Window Abstraction) │
├────────────────────────────┤
│ smithay-client-toolkit (Wayland Wrapper) │
├────────────────────────────┤
│ wayland-client (Protocol Bindings) │
├────────────────────────────┤
│ Wayland Compositor │
└────────────────────────────┘
Layers that need modification:
- iced: Add
set_visibleAPI. - winit: Implement
set_visibleon Wayland.
Modifications to winit#
Core Implementation (src/platform_impl/linux/wayland/window/mod.rs):
pub fn set_visible(&self, visible: bool) {
// According to the XDG Shell protocol:
// - "Attaching a null buffer to a toplevel unmaps the surface."
// - "The client can re-map the toplevel by performing a commit without any
// buffer attached, waiting for a configure event and handling it as usual."
let surface = self.window.wl_surface();
if visible {
{
let mut state = self.window_state.lock().unwrap();
state.set_visible(true);
// Reset frame callback state to break deadlock
state.frame_callback_reset();
}
surface.commit();
self.request_redraw();
} else {
self.window_state.lock().unwrap().set_visible(false);
// Clear pending redraw requests
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// Unmap according to protocol: attach(NULL) + commit
surface.attach(None, 0, 0);
surface.commit();
}
}
Modifications to iced#
Add Action (runtime/src/window.rs):
pub enum Action {
// ...existing actions...
/// Set the visibility of the window.
SetVisible(Id, bool),
}
/// Sets the visibility of the window.
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}
Handle Action (winit/src/lib.rs):
window::Action::SetVisible(id, visible) => {
if let Some(window) = window_manager.get_mut(id) {
window.raw.set_visible(visible);
}
}
Not a Buddy: Those Annoying Bugs#
Hyprland Compositor Crash (Why can't xdg_toplevel be destroyed?)#
Initial Plan: Referencing Chromium's implementation, destroy the xdg_toplevel to hide the window and recreate it to show the window.
Problem: On Hyprland, destroying and recreating the xdg_toplevel causes the compositor to crash, returning to the SDDM interface.
// Hyprland crash stack
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init // Crash point
Root Cause: Hyprland cannot correctly handle the situation of recreating xdg_toplevel on the same xdg_surface.
Final Plan: Completely avoid destroying xdg_toplevel, only use the wl_surface.attach(NULL) method specified by the XDG Shell protocol:
- Hide:
attach(NULL)+commit()→ surface is unmaped. - Show:
commit()+request_redraw()→ re-renders.
This plan:
- Fully complies with the XDG Shell protocol.
- Does not disrupt the
xdg_toplevellifecycle. - Is compatible with all compositors (including Hyprland).
- Results in cleaner code without complex lifecycle management.
Unable to Restore Display After Hiding#
Phenomenon: set_visible(false) successfully hides the window, but after set_visible(true), the window does not appear.
Reason: Frame callback deadlock.
┌───────────────────────────┐
│ wgpu waits for frame callback to submit buffer │
│ ↓ │
│ compositor waits for buffer to send frame callback │
│ ↓ │
│ Deadlock! │
└───────────────────────────┘
When the window is hidden (attach(NULL)), the compositor no longer sends frame callbacks. However, winit's rendering loop relies on frame callbacks to know when to render the next frame.
Solution: Reset the frame callback state when set_visible(true):
state.frame_callback_reset(); // Reset to None, allowing immediate redraw
Window Flickering When Hiding#
Phenomenon: When set_visible(false) is called, the window disappears and then flickers once, and the number of flickers accumulates each time.
Reason: Refresh logic of Client-Side Decorations (CSD).
The winit event loop periodically calls refresh_frame() to update window decorations. Even if the window is hidden, if the CSD framework considers itself "dirty," it will still trigger a redraw—this will reattach the buffer, causing the window to appear again.
Solution: Multi-layer protection:
// 1. Check visible in refresh_frame()
pub fn refresh_frame(&mut self) -> bool {
if !self.visible {
return false; // Do not refresh decorations when hidden
}
// ...
}
// 2. Check visible in request_redraw()
pub fn request_redraw(&self) {
if !self.window_state.lock().unwrap().visible() {
return; // Do not request redraw when hidden
}
// ...
}
// 3. Clear pending redraw when set_visible(false)
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);
// 4. Check visible when dispatching in event loop
if !window.visible() {
window_requests.get(window_id).unwrap().take_redraw_requested();
return None; // Do not dispatch RedrawRequested
}
Final Plan Summary#
Core Principles#
Hiding the window:
┌────────────────────────┐
│ 1. set_visible(false) │
│ 2. Set visible state to false │
│ 3. Clear pending redraw requests │
│ 4. wl_surface.attach(NULL, 0, 0) │
│ 5. wl_surface.commit() │
│ → Surface is unmaped, compositor no longer displays it │
└────────────────────────┘
Showing the window:
┌────────────────────────┐
│ 1. set_visible(true) │
│ 2. Set visible state to true │
│ 3. Reset frame_callback_state (break deadlock) │
│ 4. wl_surface.commit() │
│ 5. request_redraw() │
│ → Triggers redraw, wgpu attaches buffer, window reappears │
└────────────────────────┘
Modified Files#
| Project | File | Modification |
|---|---|---|
| winit | src/.../wayland/window/mod.rs | Implement set_visible(); check visible in request_redraw() |
| winit | src/.../wayland/window/state.rs | Add visible field; check visible in refresh_frame() |
| winit | src/.../wayland/event_loop/mod.rs | Check visible and clear pending redraw before dispatching RedrawRequested |
| iced | runtime/src/window.rs | Add SetVisible action and set_visible() function |
| iced | winit/src/lib.rs | Handle SetVisible action, call window.raw.set_visible() |
Usage#
// In iced application
Message::ToggleWindow => {
self.window_hidden = !self.window_hidden;
let visible = !self.window_hidden;
return iced::window::latest().and_then(move |id| {
iced::window::set_visible(id, visible)
});
}
References#
Protocol Documentation#
Source Code References#
Related Issues#
Author's Note: This implementation is based on winit 0.30.12 and iced 0.14.0. Adjustments may be needed for different versions.
Code Repositories:
- Rustle: https://github.com/ArcticFoxNetwork/Rustle
- winit fork: https://github.com/ArcticFoxNetwork/winit (wayland-visibility branch)
- iced fork: https://github.com/ArcticFoxNetwork/iced (wayland-visibility branch)